在前面的章節當中我們應該對元件props/emits
有一定掌握,不過在Vue中元件間的關係可能不只有上下層父子關係
,在元件粒度切分比較細時,會有事件或參數(props)傳遞較多層的問題,就會形成props drilling
現象。
Vue提供了依賴注入(provide and inject)
的API,讓我們可以跨越某些中間層組件,去達到資料傳遞功能。同時,也稍微聊聊認識一下pinia全域資料狀態管理
的使用。
provide inject
和正確使用模式provider無渲染元件
,抽離provide inject邏輯全局狀態管理pinia
的使用props drilling
是指當資料需要從父元件傳遞給深層次的子元件時,必須經過多層中間元件,這些中間元件僅僅是為了傳遞這些 props 而存在,並不真正使用這些資料。
當元件必須處理與自身功能無關的 props 以支持深層次的資料傳遞時,這些元件的重用性會降低。這是因為它們變得依賴於與其不相關的資料或邏輯。資料傳遞間也會增加了在某一層級出錯的風險。
(圖片出處)
Vue 的 provide
和 inject
是一個用於在同一元件樹中共享資料的API工具,通常用來讓祖先元件將資料傳遞給後代子孫元件,而不用透過 props 層層傳遞。這種方式可以有效避免 props drilling
問題,讓資料更容易在不同層級的元件之間共享。
不過單純這麼直接使用對資料流還不是很牢靠,因為傳遞進入子孫組件的響應式資料,是有機會被更動的,造成難以察覺的錯誤。
所以為了讓 provide
提供的資料不能讓子孫元件隨意直接修改,確保資料的安全性和一致性,可以採取以下措施:
readonly
將provider提供的數據變成唯讀,會在開發模式下提出警告。update數據的函式
,子孫組件需要更新provider數據來使用,有點像是提醒開發者有意識地知道說你在調動 provider 裡面的資料。import {provide, inject} from 'vue'
// 父元件
const sharedData = ref({ value: 'some data' })
function updateState(newValue) {
state.value = newValue;
}
provide('sharedData', sharedData));
provide('updateState', updateState);
// 深層次的子元件
const sharedData = inject('sharedData','') // inject 後面可設計預設值,避免注入名錯誤
const updateState = inject('updateState',()=>{})
如果傳遞資料的是物件(object)
,最好進行完整的複製(深拷貝)
。上次有提到因為readonly
本身不會對這些資料變更提出警告,它仍然觸發Vue攔截到直接修改整個物件的引用(reference)
,進而使資料更新到畫面上。
或者像上面一樣定義個 update funciton
,對於元件有需要對原始資料進行更新,才以inject
注入提供更新資料的功能。
// 一般型別資料provider
import { provide, readonly, ref } from 'vue';
const state = ref('This is a string');
provide('sharedState', readonly(state));
// 子孫組件使用
const sharedState = inject('sharedState');
console.log(sharedState.value); // "This is a string"
sharedState.value = 'New value'; // 將在開發模式下觸發警告,但數據仍會更新
// 物件型別provider
import { ref, readonly, provide } from 'vue';
const state = ref({
nested: {
value: 'This is a nested value'
}
});
// 創建深拷貝
const deepCopyState = JSON.parse(JSON.stringify(state));
provide('sharedState', readonly(deepCopyState));
如果我們有很多個 provider
,都定義在頂層組件上的話,會使得該組件中的響應式定義資料變數顯得比較肥大,我們其實可以把它抽離出來,製作成一個 provider 資料元件
,是一種只負責傳遞資料的無渲染組件(renderless component)
。上次介紹 slot props
的文章,是將互動按鈕的邏輯製作在無渲染元件中,其實它也可以製作變成資料傳遞元件(data provider component)
。
我們來實際做一個專門提供 provider資料
的無渲染元件:
運用昨天我們已經提到的組合式函式概念,先寫一個 useProvider
,將要共享的邏輯包裝起來。如果有很多個不同邏輯的 provider,也可以分開檔案再引入集中邏輯,或直接定義另外的組合式函式,這樣可以使代碼結構更加清晰、模組化,並且便於維護和擴展。
// useProvider.js
import { ref, provide, readonly } from 'vue';
export function useProvider() {
const state = ref({
value: 'Hello World',
count: 0,
});
const increment = () => {
state.count++;
};
// 補上readonly 防止資料意外被竄改
provide('sharedState', readonly(state));
provide('increment', increment);
}
export function useProvider2() {
const state2 = ref({
value: 'Provider 2 state'
});
provide('provider2State', readonly(state2));
}
在 provider component
中引入剛才寫好的 useProvider
,綁入元件setup
中初始化響應式資料。
<template>
<div>
<slot></slot>
</div>
</template>
<script setup>
import { useProvider,useProvider2 } from './useProvider';
// 調用 useProvider 綁入共享資料
useProvider();
useProvider2()
</script>
使用無渲染的 Provider 組件來包裹頂層組件(root component)
,這樣底下的子孫組件都能共享 Provider 提供的數據或方法,使用起來跟React useContext 感覺滿類似的:
<template>
<ProviderComponent>
<MyConsumerComponent />
<AnotherConsumerComponent />
</ProviderComponent>
</template>
<script setup>
import ProviderComponent from './ProviderComponent.vue';
import MyConsumerComponent from './MyConsumerComponent.vue';
import AnotherConsumerComponent from './AnotherConsumerComponent.vue';
</script>
認識完 provide/inject
是 Vue 提供的一種依賴注入機制,能夠在父子組件之間傳遞資料。但是隨著應用規模擴大,而且這些元件彼此間式沒有上下游關係,但又需要共享狀態時,provide/inject
的靈活性可能會受到限制。這時可以用全局狀態管理工具如 Pinia
或 Vuex
來統一管理應用的狀態,會比較合適一些。
Pinia
和 Vuex
的主要區別之一是 Pinia 不再強調將狀態管理切割成「模塊」
的概念,而是通過「單一」或「多個」 store
來管理不同的狀態區域。Pinia 的設計能讓每個 store 都成為一個獨立的單位
,並且避免了 Vuex 中的 module
定義和註冊繁瑣的過程和模組命名上的衝突。
(圖片出處)
在使用 Pinia
的 Store
時,我們可以透過 Vue Composition API 中的 ref
、computed
和 function
來輕鬆定義 Store 的資料狀態
、讀取數據
及行為
。
以Composition API 來設計 Store 中,可以依循這樣規則來分類:
ref()
就是 state
屬性computed()
就是 getters
屬性function()
就是 actions
屬性export const useCounterStore = defineStore('counter', () =>
const count = ref(0) // 原始資料
const doubleCount = computed(() => count.value * 2) // 只能讀取
// 更新數據動作
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
// main.js Vue實例掛載註冊使用Pinia
import { createPinia } from 'pinia'
// retrieve the rootState server side
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
Note that store is an object wrapped with reactive, meaning there is no need to write .value after getters but, like props in setup, we cannot destructure it:
Pinia 中的 Store 是通過 reactive 將其狀態包裝成響應式物件
,因此跟之前提到過響應式資料reactive
一樣,解構賦值會喪失響應性,如果需要對 Pinia Store 中取出來的資料進行操作,又能達到響應式更新的話需要使用storeToRef
轉成ref物件
。
<script setup>
import { storeToRefs } from 'pinia'
const store = useCounterStore()
// name 和 doubleCount 會轉成ref物件並和store裡面定義的資料連動
const { name, doubleCount } = storeToRefs(store)
name.value = 'abc' // 這裡會更動到store資料
// 作為 action 的 increment 函式則式可以直接使用
const { increment } = store
</script>
const { name, doubleCount } = storeToRefs(store)
name.value = 'abc' // 這裡會更動到store資料
但我們可以發現是上面直接引用 Store 數據很方便有缺點是,引入元件利用storeToRef
轉成ref物件
的資料都能直接隨意更動的話,當共用狀態元件一多時,可能會有蠻難追蹤的問題。
目前官方文件是說明不建議使用私有屬性
,建議是Store裡面定義的所有資料和方法能夠一併忠實返回,能夠讓 devtool 方便調適,SSR渲染時也比較不會出現問題。或者使用 computed
官方推薦動作等去作讀取動作:
不過看到有人在社群討論區裡面寫的案例:
跟上面提到一樣使用 readonly
,對不想被隨意更動的資料進行封裝,在開發模式下元件賦值時會出現警示,當然我們可以另外定義 updateUser
讓使用者去更新資料或者有需要的元件才引入使用,避免資料過度暴露各元件上而有太多更動風險存在。
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
defineStore('user', () => {
const user = ref(null)
function updateUser(val) {
user.value = val
}
return { user: readonly(user), updateUser } // readonly()
})
分享自己簡單應用上的選擇思路,實務上每個團隊習慣可能不同,可以根據規範自己選用。
像是一個頂層的組間表單組件,這個表單包含多個子元件(如輸入框、選擇框等),這些子元件需要共享表單的狀態,例如表單的有效性、錯誤訊息等。
表單的狀態因為不太需要在整個應用程式中共享,只需要在這個表單元件及其子元件中傳遞使用
,這種情況可以用簡單的Provide-Inject模式處理。
例如一個大型電子商務應用,這個應用有多個組件(如用戶管理、購物車、產品展示等),它們可能散落在不同頁面上
,但每個組件可能需要共享用戶的身份驗證狀態
,購物車內容、產品信息等,才能執行後續的功能。
provide/inject
用於在元件樹中的祖先組件和後代組件之間共享數據或方法,無需通過中間層層傳遞 props 適合用於簡單的數據共享場景,特別是在有明確父子關係的小型應用或功能
。
使用上雖然也能夠將 provide/inject 掛在 App.vue 全局應用頂層元件下使用,但在相對大型應用或複雜狀態管理中,此時可能需要引入 Pinia 這類全局的狀態管理工具
。